feat(agent): durable agent sessions with recovery, retention, and drafts (w5b) - #5365
Conversation
Add withInfiniteRetention, patchInfiniteEntity, reconcileFirstPage, and scheduleCacheMaintenance so infinite-query owners share one bounded-retention contract. Register the new lib/query test glob in the pure vitest config.
… point A cloud.message.failed event now keeps a status:failed pending entry instead of deleting it, so the row keeps its recovery affordance. Failed entries survive queue.changed and connected, reset clears them, and a later queued replaces them. Add clearFailedMessage to the service state and SessionManager.
… race When NetInfo reports online while the first socket is still establishing, refreshAndConnect awaited refreshAuth and then closed the now-healthy socket and opened a second one. Re-validate after the refresh gap so an established socket is never replaced. Add a test asserting one socket per open.
…ecovery Add a failure footer to the message bubble: Failed to deliver with Retry and Copy to composer for a failed user row, Response failed with Retry only for a failed assistant row. selectMessageFailure maps reasons to fixed copy and never emits raw provider text. Wrap MessageBubble in React.memo.
Wrap the stored-list and search builders with withInfiniteRetention, reconcile the sessions list to page one instead of a blanket invalidation, and defer the onSettled and departure maintenance behind InteractionManager so navigation never waits on cache work.
Replace the parts copy-on-write with a stable map plus a partsRevision atom so a token delta no longer clones the whole parts map. Derived atoms read both parts and partsRevision, and a StoredMessage memo keeps unchanged rows' identity.
Wrap the security-findings and PR-review owners with withInfiniteRetention and reconcile findings-list refreshes to page one. Keep offset pagination and the discussion conversation correct under the retention bound.
Expose a ChatComposerControl.setText ref so a failed row can copy its prompt back into the composer, and wire Retry to re-send the correct prompt through handleSend, clearing the failed row only on success. Count only non-failed pending messages for the working indicator and keep-awake.
…tory Coalesce applyPartDelta publication behind an injectable frame scheduler so a token stream publishes once per frame, and keep getParts correct before the flush. Add trimRetainedHistory with a 200-message window that drops the oldest loaded page and restores its cursor.
The failed-row retry wiring in a226cb1 imports countInFlightMessages and retryMessageAndClear from session-detail-content-helpers, but the module and its test were left untracked. Commit them so the branch typechecks and tests on a clean checkout.
Prefer fetchSnapshotPage over fetchSnapshot in hydrateChildSession so a child sheet loads a bounded page instead of the unbounded snapshot. Add per-child cursor, hasOlder, isLoadingOlder, olderError, and omittedItemCount to the ready hydration state, and loadOlderChildMessages to page a child by its own cursor without touching root pagination state.
Reject oversized clipboard images before they reach disk, bound the chip thumbnail decode, and expose the upload task's cancelAsync so remove, reset, session switch, and unmount cancel in-flight uploads without a toast. Delete cache-owned partial files but never picker-provided URIs.
Skip the first focus refetch (owned by switchSession) while keeping the 4s pending-decision follow-up, and wire the child sheet's pagination props through the per-child hydration state including the omitted-item count.
retainConversationAcrossMounts takes ConversationComment[], so the test's bare
{ id: 'c1' } comment no longer typechecks. Cast it to ConversationComment.
checkAndSchedule now reports whether the session data has landed, and the skip path subscribes only when it has not. A later write no longer starts a second 4-second timer, so the follow-up stays a single refetch.
Wire the SessionMessageList onReachedBottom transition to SessionManager.trimRetainedHistory so older loaded pages are dropped only when the view returns to the bottom. W4.3 confirmed the markdown renderers already memoize the parse on value; no renderer change was needed.
Add createFrameCoalescer and route composer derived setters and per-attachment upload progress through it, publishing at most once per animation frame. Flush at submit, share-prefill, and terminal-value decision points; cancel fully disables a coalescer so a cancelled upload publishes no late progress.
Add prMerge/prReply/prComment draft keys and an isMergeDraft validator, make useFencedDraftLoad generic with a defaulted validator, and wire the merge sheet, PR reply, and PR comment composer to load, save, and clear durable drafts per account and destination. Clear only on completion or confirmed discard.
…uites Add a max-lines disable and a non-empty no-op to pr-merge-sheet.test.tsx, and mock useImperativeHandle in chat-composer-attachment-send.test.ts so the plain-function render no longer throws.
Hide the reply field and disable submit until the durable draft settles, matching the merge and comment surfaces, so a reused instance cannot save the previous thread's text under a new key.
Code Review SummaryStatus: No Issues Found | Recommendation: Merge Executive Summary
Files Reviewed (11 files)
Previous Review Summaries (6 snapshots, latest commit b4707f5)Current summary above is authoritative. Previous snapshots are kept for context only. Previous review (commit b4707f5)Status: No Issues Found | Recommendation: Merge Executive SummaryQueue, trim, and upload-cancel review fixes match the requested contracts; no new correctness issues. Files Reviewed (8 files)
Previous review (commit 48edc0f)Status: No Issues Found | Recommendation: Merge Executive SummaryPost-merge anti-slop lint and assistant failure-copy lookup are equivalent refactors; no new correctness issues. Files Reviewed (14 files)
Previous review (commit faa792f)Status: No Issues Found | Recommendation: Merge Executive Summary
Files Reviewed (2 files)
Previous review (commit 062806d)Status: 1 Issue Found | Recommendation: Address before merge Executive Summary
Overview
Issue Details (click to expand)WARNING
Files Reviewed (16 files)
Fix these issues in Kilo Cloud Previous review (commit cdbc19d)Status: 8 Issues Found | Recommendation: Address before merge Executive SummaryAfter Overview
Issue Details (click to expand)WARNING
Files Reviewed (3 files)
Fix these issues in Kilo Cloud Previous review (commit d565aa1)Status: 8 Issues Found | Recommendation: Address before merge Executive SummaryAfter Overview
Issue Details (click to expand)WARNING
Files Reviewed (70 files)
Reviewed by grok-4.6 · Input: 101.8K · Output: 9.5K · Cached: 261.1K Review guidance: REVIEW.md from base branch |
patchInfiniteEntity has no production caller; the jotai benchmark file is a measurement harness, not a test.
…rture reconcileFirstPage kept pages[0], which is not page one after maxPages evicts the oldest page from the front. Reset pages/pageParams to empty so invalidate refills page one from initialPageParam. The departure-triggered stored reset now uses reconcileFirstPage instead of a plain refetch, which only refreshed pages still in cache and never reloaded a just-terminated session after page one was evicted.
Bump the generation fence during the identity-change render, not only in effect cleanup. An in-flight load whose read already resolved could publish the previous account's or entity's draft as a microtask before cleanup ran.
Seed bodyRef during render, before the field mounts, matching the merge and comment surfaces. The previous effect-based seed let a reused instance paint the prior thread's text and save it under the new key.
Register the cancel handle before the presign await so remove/reset/unmount during that window still deletes the cache-owned file, and delete the file in a finally so cleanup does not depend on cancelAsync succeeding.
Extract firstHumanText and use it for retry and copy-to-composer. Retry no longer re-sends synthesized attachment notices and is suppressed for a file-only row; copy-to-composer hides when there is no human text.
firstHumanText skipped synthetic parts, so a queued prompt that failed delivery had no human text and Retry/Copy disappeared. Return the first text part regardless of synthetic, skipping only ignored parts.
…d08d # Conflicts: # apps/mobile/src/components/agents/session-detail-content.tsx
Summary
A failed delivery or failed response now identifies its own message and offers Retry; a failed delivery also offers Copy to composer.
Long conversations stay responsive: message updates are batched to one render per frame, and older history is trimmed automatically once you return to the newest messages.
Session lists, security findings, and PR review lists no longer grow without limit as you keep scrolling.
An oversized clipboard image is rejected before it is processed, and a removed, reset, or abandoned attachment cancels its upload and cleans up its partial file.
No new behavior — the change removes a rare duplicate connection, not a visible feature.
No new behavior — the change makes opening a session and loading a child session read one bounded page instead of everything.
Merge, reply, and review-comment drafts survive an app restart and are restored when you return; they clear only on explicit discard or a completed action.
Typing and upload progress update once per frame, keeping the composer smooth during long streams.
The parts store is one stable map mutated in place and published through a
partsRevisioncounter. Per-message text deltas flush once per frame, and structural operations flush pending work first. The scheduler isrequestAnimationFrameon device and synchronous under node.Files
packages/cloud-agent-sdk/src/storage/jotai.ts— replaces the per-delta parts-map clone with a stablepartsMapplus apartsRevisionatom and a coalesced one-flush-per-frame scheduler.The session manager gains
clearFailedMessage, a memoized per-rowStoredMessagecache, a 200-row retained-history window (trimRetainedHistory), and paged child-session hydration (loadOlderChildMessages). Unchanged rows keep object identity across a delta on another row, whichReact.memorelies on. The retained window trims the oldest loaded older-page only when the transcript returns to the bottom.Files
packages/cloud-agent-sdk/src/session-manager.ts— addsclearFailedMessage,memoizedStoredMessagewith a pruned memo,trimRetainedHistorywith aretainedHistoryStack, andloadOlderChildMessageswith an expandedChildSessionHydrationState.Failed delivery entries survive reconnect and queue reconciliation, and
clearFailedMessageremoves one after a successful retry. Thequeue.changedandconnectedpaths preservefailedentries instead of dropping them.Files
packages/cloud-agent-sdk/src/service-state.ts— preservesfailedpending-message entries throughqueue.changedreconciliation and reconnect; addsclearFailedMessage.A failed delivery or assistant turn renders a footer with fixed, safe copy. Delivery rows offer Retry and Copy-to-composer; assistant rows offer Retry only, and non-retryable assistant errors suppress it. Retry and Copy use the row's first human-authored text part, so Copy hides on a file-only row while a synthetic queued prompt keeps both.
Files
apps/mobile/src/components/agents/message-failure-state.ts— new selector that maps a delivery reason or assistant error name to fixed copy and retry/copy flags.apps/mobile/src/components/agents/message-bubble.tsx— renders the failure footer (title, detail, Retry, Copy-to-composer) when the relevant handler is wired; Copy uses the first human text part and hides when it is empty.apps/mobile/src/components/agents/part-types.ts— addsfirstHumanText, which returns the first text part regardless ofsynthetic(skipping onlyignoredparts).The session detail screen wires Retry and Copy-to-composer through the existing send path, trims retained history on return-to-bottom, pages child-session sheets, and refetches the linked PR on later focus.
countInFlightMessagesexcludes failed rows so the spinner and wake lock do not stay on after a terminal failure.Files
apps/mobile/src/components/agents/session-detail-content.tsx— resolves the retry prompt per row kind, wires Retry throughhandleSendand Copy-to-composer throughsetText, callstrimRetainedHistoryon return-to-bottom, and threads child pagination and the focus refetch.apps/mobile/src/components/agents/session-detail-content-helpers.ts— newcountInFlightMessages,retryMessageAndClear, andresolveRetryPrompt(which resolves the retry prompt from the first human text part).apps/mobile/src/components/agents/session-message-list.tsx— addsonReachedBottom, fired only on the false→true bottom transition.apps/mobile/src/components/agents/child-session-sheet.tsx— threads the per-child pagination props into the message list.apps/mobile/src/components/agents/session-focus-refetch.ts— newshouldRefetchOnFocushelper that owns the first-focus-vs-later-focus decision.The composer exposes an imperative
setTexthandle for Retry and Copy-to-composer, and coalesces its derived typing state to one publication per frame. A newcreateFrameCoalescerprimitive backs the coalescing.Files
apps/mobile/src/components/agents/chat-composer.tsx— adds theChatComposerControlhandle (setText) and coalesces the measure/hasText/slash-command setters, flushing on unmount and before submit.apps/mobile/src/lib/coalesce-frame.ts— new frame coalescer withpush,flush, andcancel.Infinite queries are bounded.
withInfiniteRetention,reconcileFirstPage, andscheduleCacheMaintenancecap loaded pages (default 5), and agent sessions/search, security findings, PR-review discussion threads, and the PR-review file list all adopt them.reconcileFirstPageempties the cached pages to page one rather than keepingpages[0], so a refetch aftermaxPagesevicts page one starts from the correct cursor.Files
apps/mobile/src/lib/query/infinite-retention.ts— new retention primitive (max-pages merge, first-page reconcile that empties pages to page one, deferred maintenance).apps/mobile/src/lib/hooks/use-agent-sessions.ts— applies retention to the stored-sessions and search infinite queries; the departure effect resets the stored list to page one before refetching.apps/mobile/src/lib/hooks/use-security-findings.ts— applies retention and derives the next offset fromlastPageParamso trimming cannot repeat an offset.apps/mobile/src/lib/hooks/use-security-agent-commands.ts— reconciles the findings first page on command completion.apps/mobile/src/lib/hooks/use-security-agent-mutations.ts— reconciles the findings first page after config save and sync.apps/mobile/src/lib/hooks/use-security-remediation.ts— reconciles the findings first page after remediation start/retry/cancel.apps/mobile/src/lib/hooks/use-session-mutations.ts— defers session invalidation through cache maintenance.apps/mobile/src/lib/agent-session-cache.ts— trims the stored list to page one before refetching.apps/mobile/src/lib/pr-review/diff/pr-review-file-list-state.ts— applies retention capped atPR_REVIEW_MAX_PAGES.apps/mobile/src/lib/pr-review/discussion/use-pr-review-discussion-threads.ts— applies retention and retains the first-page conversation across the trim and remount.apps/mobile/vitest.pure.config.ts— registers the newsrc/lib/query/**/*.test.tsglob so the retention tests run.Attachment handling is bounded and cancellable. Oversized clipboard images are rejected before any file is written, thumbnails downscale, and uploads cancel on remove/reset/unmount/session-switch with partial cache-file cleanup. Per-attachment upload progress is coalesced to one publication per frame.
Files
apps/mobile/src/lib/agent-attachments/clipboard-image.ts— addsdecodedBase64ByteLengthand rejects an oversized clipboard image before writing the cache file.apps/mobile/src/lib/agent-attachments/use-clipboard-paste.ts— surfaces thetoo-largereason without falling through to the text path.apps/mobile/src/lib/agent-attachments/use-agent-attachment-upload.ts— adds per-attachment cancel handles registered before the presign, cache-owned file cleanup that runs even when the cancel rejects, a stale-outcome guard, and coalesced progress.apps/mobile/src/lib/agent-attachments/upload-task.ts— exposes the upload task viaonTaskso the caller can cancel it.apps/mobile/src/components/agents/attachment-preview-strip.tsx— addsallowDownscaling, a recycling key, and memory cache policy to the thumbnail.apps/mobile/src/components/agents/new-session-prompt.tsx— passes the attachment byte bound to the clipboard paste.Merge, reply, and review-comment drafts are durable.
prMergeDraftKey,prReplyDraftKey, andprCommentDraftKeyjoin the draft store,useFencedDraftLoadbecomes generic (its return field renamestexttovalue), and the three surfaces persist by account and destination, restoring after process loss and clearing only on explicit discard or authoritative completion.Files
apps/mobile/src/lib/persist/drafts.ts— adds the three PR draft keys and theisMergeDraftshape guard.apps/mobile/src/lib/persist/use-draft-load.ts— makesuseFencedDraftLoadgeneric with avalidateguard; renames the returnedtexttovalue; fences the load on the identity-change render so a stale account or entity value can never publish.apps/mobile/src/components/pr-review/discussion/reply-input.tsx— persists and restores the reply draft keyed by account and thread; seeds the field once per identity/thread during render.apps/mobile/src/components/pr-review/merge/pr-merge-sheet.tsx— persists and restores the merge draft, clearing on merge or explicit cancel.apps/mobile/src/components/pr-review/pr-review-comment-composer.tsx— persists and restores the create-mode comment draft, clearing on post/add-to-review or explicit discard.apps/mobile/src/app/(app)/agent-chat/new.tsx— updates the new-session draft read to the renamedvaluefield.The duplicate WebSocket connect is fixed. The auth-refresh reconnect path re-checks for an already-open socket after the async refresh resolves, so the NetInfo
unknown → onlinerace cannot open a second/streamsocket.Files
packages/cloud-agent-sdk/src/base-connection.ts— adds the open-socket guard inrefreshAndConnect.Test: 33 test files changed (including 1 fixture and 1 test helper). Generated: none.
Verification
Visual Changes
Visual Changes: N/A
Reviewer Notes
No human steps are required before or after merge.
P1-A-07b satisfied by #5093; no duplicate instrumentation added.P1-G-54a: duplicate initial history replay measured (87 events re-delivered per open) and kept — using the page watermark as the socket cursor would drop unmaterialized events, and closing it needs a server-side materialized-watermark field this package does not contain.